Le challenge consiste à retrouver la position exacte d'un ensemble de messages émis par des objets connectés pouvant être en mouvement ou non (devices), à partir des informations reçues par les stations Sigfox qui les ont captés.
2 jeux de données ont été fournis :
À FAIRE !
#########################################
# Imports et paramétrage des graphiques #
#########################################
%matplotlib inline
import warnings
warnings.filterwarnings('ignore')
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from geopy.distance import vincenty
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
from sklearn.ensemble import ExtraTreesRegressor
from sklearn import metrics
import seaborn as sns
from IPython.core.display import display, HTML
from IPython.display import Image, IFrame
from scipy.stats import gaussian_kde
from statsmodels.nonparametric.kde import KDEUnivariate
from sklearn import model_selection
sns.set_style("white")
# Parameters of the plots
SMALL_SIZE = 12
MEDIUM_SIZE = 14
BIGGER_SIZE = 16
plt.rc('font', size=SMALL_SIZE) # controls default text sizes
plt.rc('axes', titlesize=BIGGER_SIZE) # fontsize of the axes title
plt.rc('axes', labelsize=MEDIUM_SIZE) # fontsize of the x and y labels
plt.rc('xtick', labelsize=SMALL_SIZE) # fontsize of the tick labels
plt.rc('ytick', labelsize=SMALL_SIZE) # fontsize of the tick labels
plt.rc('legend', fontsize=SMALL_SIZE) # legend fontsize
Après le chargement des données dans des DataFrame Pandas, nous avons réalisé une première exploration pour mieux en comprendre les caractéristiques.
Le dataframe d'apprentissage est décrit par les colonnes suivantes.
Comme le montrent nous premières explorations, ce jeu d'apprentissage comporte :
Le jeu de test final est quant à lui constitué de :
D'une manière générale l'ensemble des données (apprentissage + test) représente :
Comme le montre la carte ci-dessous, on constate que la grande majorité des stations et des messages émis se trouvent aux Etats-Unis et plus précisément fortement concentrés autour de la ville de Denver, dans l'état du Colorado. Cependant la distribution globale des messages et des stations montrent qu'ils semblent suivre un segment de droite qui part du centre du Wyoming et descend jusqu'à la pointe du Texas et l'extrémité ouest de l'Oklahoma.
Légende de la carte selon les points
Image(filename='VisionGobaleMessagesStations.png', width=800)
Zoom centered on Denver
Image(filename='ZoomDenver.png', width=800)
Cependant nos différentes visualisations et recherches nous ont montré qu'un certain nombre de stations étaient mal positionnées - probablement des données modifiées à dessein pour l'exercice (!). En effet 23 stations apparaissent positionnées très loin au Canada, près du Groënland, où Sigfox n'a pas de station selon le site web officiel. De plus ces 23 stations ont toutes les mêmes coordonnées de longitude et de latitude, ce qui est très suspicieux. Enfin, ces stations mal positionnées reçoivent pourtant des messages émis du Colorado...
Ces éléments nous ont amenés à trouver des moyens pour corriger et prédire la position initiale de ces stations, de façon à améliorer notre score, comme cela est décrit plus loin.
Image(filename='DezoomStationsWorld.png', width=500)
Pour les graphiques suivants, on peut observer les éléments suivants :
Pour les stations :
La visualisation suivante a été réalisée avec le package folium qui permet d'avoir une interactivité avec la carte.
import folium
def createFoliumCarte():
df_mess_train = pd.read_csv('mess_train_list.csv')
df_mess_test = pd.read_csv('mess_test_list.csv')
df = pd.read_csv('pos_train_list.csv')
# Unique message
id_m = df_mess_train['messid']
lat = df['lat']
lng = df['lng']
locations_m_= pd.concat([id_m,lat,lng],axis=1)
lm = locations_m_.groupby('messid').mean()
locations_message = list(lm.values)
#Unique station
lat_bs = df_mess_train['bs_lat']
lng_bs = df_mess_train['bs_lng']
locations_bs_ = pd.concat([df_mess_train['bsid'],lat_bs,lng_bs],axis=1).groupby('bsid').mean()
location_bs = list(locations_bs_.values)
bsid_importance = df_mess_train.groupby(['bsid']).count()["messid"]
bsid_localisation = df_mess_train.groupby(['bsid']).mean()[['bs_lat','bs_lng']]
bsid_importance_100 = bsid_importance[bsid_importance>=100]
listbsid_importance_100 = bsid_importance_100.iloc[:].index
bsid_importance_50 = bsid_importance[bsid_importance<100]
listbsid_importance_50 = bsid_importance_50.iloc[:].index
bsid_importance_1 = bsid_importance[bsid_importance==1]
bsid_importance_1
listbsid_importance_1 = bsid_importance_1.iloc[:].index
bsid_importance_2 = bsid_importance[bsid_importance==2]
bsid_importance_2
listbsid_importance_2 = bsid_importance_2.iloc[:].index
#Coodinates and bsid
station_lessimportant = locations_bs_.loc[listbsid_importance_1]
station_lessimportant2 = locations_bs_.loc[listbsid_importance_2]
station_important_50 = locations_bs_.loc[listbsid_importance_50]
station_important_100 = locations_bs_.loc[listbsid_importance_100]
data = df_mess_train.groupby(['messid']).count()["bsid"]
data_with_3_pos_more = data[data>3].count()
data_with_3_pos = data[data==3].count()
data_with_2_pos = data[data==2].count()
data_with_1_pos = data[data==1].count()
mess1 = lm.loc[data[data==1].index]
mess2 = lm.loc[data[data==2].index]
mess3 = lm.loc[data[data==3].index]
mess4 = lm.loc[data[data>3].index]
cm = folium.Map([35, -109], zoom_start=6)
for i, loc in enumerate(station_lessimportant.values):
folium.CircleMarker(location=loc, radius=20,color='c',fill_color='c').add_to(cm)
for i, loc in enumerate(station_important_50.values):
folium.CircleMarker(location=loc, radius=20,color='darkgreen',fill_color='darkgreen').add_to(cm)
for i, loc in enumerate(station_important_100.values):
folium.CircleMarker(location=loc, radius=20,color='darkblue',fill_color='darkblue').add_to(cm)
for i, loc in enumerate(mess1.values):
folium.CircleMarker(location=loc, radius=2,color='yellow',fill_color='yellow').add_to(cm)
for i, loc in enumerate(mess2.values):
folium.CircleMarker(location=loc, radius=2,color='magenta',fill_color='magenta').add_to(cm)
for i, loc in enumerate(mess3.values):
folium.CircleMarker(location=loc, radius=2,color='red',fill_color='red').add_to(cm)
for i, loc in enumerate(mess4.values):
folium.CircleMarker(location=loc, radius=2,color='pink',fill_color='pink').add_to(cm)
return cm
from IPython.display import IFrame
IFrame("Visualisation.html", width='100%', height=500)
Nous avons procédé à plusieurs visualisations et recherches pour mieux comprendre la distribution de chacun des features, à la fois dans le jeu d'apprentissage et dans le jeu de test. Les commentaires suivent le code et les graphiques.
#############################################################
# Chargement des données à partir des fichiers csv de base #
#############################################################
df_mess_train = pd.read_csv('mess_train_list.csv')
df_mess_test = pd.read_csv('mess_test_list.csv')
pos_train = pd.read_csv('pos_train_list.csv')
plt.figure(figsize=(16,12))
plt.subplot(3,2,1)
plt.hist(df_mess_train['bs_lng'], bins= 100, label='bs_train')
plt.hist(pos_train['lng'], bins= 100, label='mess_train')
plt.ylabel("Frequency")
plt.xlabel("Longitude")
plt.legend()
plt.subplot(3,2,2)
plt.hist(df_mess_train['bs_lat'], bins= 100, label='bs_train')
plt.hist(pos_train['lat'], bins= 100, label='mess_train')
plt.ylabel("Frequency")
plt.xlabel("Latitude")
plt.legend()
plt.subplot(3,2,3)
x_grid = np.linspace(-150, -80, 1000)
kde_rssi = KDEUnivariate(df_mess_train['rssi'])
kde_rssi.fit(bw=5, kernel='gau')
pdf_rssi = kde_rssi.evaluate(x_grid)
kde_rssi_test = KDEUnivariate(df_mess_test['rssi'])
kde_rssi_test.fit(bw=5, kernel='gau')
pdf_rssi_test = kde_rssi_test.evaluate(x_grid)
plt.fill_between(x_grid, pdf_rssi, alpha=0.5, label='rssi_train')
plt.ylabel("Frequency")
plt.xlabel("rssi")
plt.fill_between(x_grid, pdf_rssi_test, alpha=0.5, label='rssi_test')
plt.ylabel("Frequency")
plt.xlabel("rssi")
plt.legend()
plt.subplot(3,2,4)
plt.hist(df_mess_train['nseq'], bins= 100, label='train')
plt.hist(df_mess_test['nseq'], bins= 100, label='test')
plt.ylabel("Frequency")
plt.xlabel("nseq")
plt.legend()
plt.subplot(3,2,5)
plt.hist(df_mess_train['time_ux'], bins= 100, label='train')
plt.hist(df_mess_test['time_ux'], bins= 100, label='test')
plt.ylabel("Frequency")
plt.xlabel("time_ux")
plt.legend()
plt.subplot(3,2,6)
plt.hist(df_mess_train['bsid'], bins= 100, label='train')
plt.hist(df_mess_test['bsid'], bins= 100, label='test')
plt.ylabel("Frequency")
plt.xlabel("bsid")
plt.legend()
df_mess_pos = df_mess_train.copy()
df_mess_pos[['lat', 'lng']] = pos_train
with sns.axes_style('white'):
small_df=df_mess_pos[['rssi', 'time_ux', 'bs_lat', 'bs_lng', 'lat', 'lng']]
pd.plotting.scatter_matrix(small_df, figsize=(14, 14), diagonal="kde", alpha=0.2)
plt.show()
Les corrélations ci-dessus montrent également qu'il y a une relation linéraire évidente entre les latitudes/longitudes des stations et les latitudes/longitudes des messages, ce que l'on a vérifié sur les cartes.
plt.figure(figsize=(15,5))
plt.subplot(1,2,1)
df_mess_bs_group = df_mess_train.groupby(['messid'], as_index=False)
df_mess_bs_group.count().sort_values(by='bsid',ascending=False)["bsid"].hist(bins=100)
plt.xlabel("Répartition des messages")
plt.ylabel("Nombre de messages")
plt.title("Fréquence des messages reçus par station train")
plt.subplot(1,2,2)
df_mess_bs_group_test = df_mess_test.groupby(['messid'], as_index=False)
df_mess_bs_group_test.count().sort_values(by='bsid',ascending=False)["bsid"].hist(bins=100)
plt.xlabel("Répartition des messages")
plt.ylabel("Nombre de messages")
plt.title("Fréquence des messages reçus par station test")
df_count_messid = df_mess_train.groupby(['messid'], as_index=False).count()
df_count_messid[(df_count_messid['bsid'] == 1)].shape[0]
df_count_messid[(df_count_messid['bsid'] == 1) | (df_count_messid['bsid'] == 2)].shape[0]
data = df_mess_train.groupby(['messid']).count()["bsid"]
data_with_3_pos_more = data[data>3].count()
data_with_3_pos = data[data==3].count()
data_with_2_pos = data[data==2].count()
data_with_1_pos = data[data==1].count()
print(f"NTotal number of messages: {data.shape[0]}")
print("Messages received by more than 3 stations : %2.2f" %(data_with_3_pos_more / data.shape[0] * 100), "%")
print("Messages received by 3 stations: %2.2f" %(data_with_3_pos / data.shape[0] * 100), "%")
print("Messages received by 2 stations: %2.2f" %(data_with_2_pos / data.shape[0] * 100), "%")
print("Messages received by one station: %2.2f" %(data_with_1_pos /data.shape[0] * 100), "%")
Nous avons constaté les répartitions suivantes des messages reçus par station. Parmi les 6068 messages émis :
Autrement dit, plus d'un tiers des messages n'ont été reçus que par 1 ou 2 stations. Ce dernier constat signifie qu'il serait difficile de procéder à une triangularisation pour retrouver la position originale de ces messages.
df_mess_all = pd.concat([df_mess_train, df_mess_test])
df_bsid_count_mess = df_mess_all.groupby(['bsid'], as_index=False).count()\
.sort_values(by='messid',ascending=False)[['bsid','messid']]
df_bsid_count_mess.columns = ['bsid','bs_count_mess']
df_bsid_count_mess.head()
df_bsid_count_mess.describe()
En moyenne une station recoit 265 messages sur les deux datasets(train et test) reunis.
Le maximum de messages recus par une station (1859) est de 1796. Il existe aussi des stations qui ont recu qu'un seul message.
plt.figure(figsize=(15,5))
plt.subplot(1,2,1)
df_mess_bs_group = df_mess_train.groupby(['did'], as_index=False)
df_mess_bs_group.count().sort_values(by='messid',ascending=False)["messid"].hist(bins=100)
plt.xlabel("Répartition des messages")
plt.ylabel("Nombre de messages")
plt.title("Fréquence des messages reçus, par device train")
plt.subplot(1,2,2)
df_mess_bs_group_test = df_mess_test.groupby(['did'], as_index=False)
df_mess_bs_group_test.count().sort_values(by='messid',ascending=False)["messid"].hist(bins=100)
plt.xlabel("Répartition des messages")
plt.ylabel("Nombre de messages")
plt.title("Fréquence des messages reçus, par device test")
df_mess_bs_group.count().describe()
df_mess_train[df_mess_train["did"]==476185.0].groupby(["bsid"], as_index=False).count()\
.sort_values(by="messid", ascending=False).head(20)
De ces chiffres, on constate plusieurs faits intéressants :
Nous pourrions réfléchir à un traitement spécifique pour ce device. La carte ci-dessous montre les messages émis par ce device en verts et les stations qui les ont reçus en rouge. Ce qui est certain c'est que ce device a beaucoup voyagé ! Comme nous ne disposons pas de l'échele de temps, il est impossible de savoir si cela est réaliste ou non. Il pourrait d'agir d'un coursier, par exemple.
Image(filename='Device476185.png', width=500)
Notre but final étant d'obtenir une prédiction fiable et non sur-apprise, nous avons séparé le jeu d'entraînement "train" en deux :
Pour que nos prédictions soient bonnes et non biaisées, les caractéristiques du jeu de validation doivent refléter le plus fidèlement possible les caractéristiques du jeu de test final sur lequel nous sommes évalués.
Nous avons donc fait en sorte de créer un jeu de validation ne comportant aucun device présent dans le jeu d'entraînement : ainsi notre algorithme ne "trichera pas" à l'entraînement et réalisera des prédictions sur des devices qu'il n'a jamais vus.
La création de ces jeux de données a été sauvegardée dans 2 fichiers CSV :
# Sort train dataset by device
df_mess_pos_sort = df_mess_pos.copy()
df_mess_pos_sort = df_mess_pos_sort.sort_values(by='did', ascending=True)
# Create the devices list
did_list_train = np.unique(df_mess_train['did'])
did_list_df = pd.DataFrame(did_list_train)
did_list_df.columns = ['did']
# Split devices into train and validation subsets, shuffle=True
did_list_train, did_list_test = model_selection.train_test_split(did_list_df, test_size = 0.25, shuffle=True)
did_list_train = did_list_train.sort_values(by='did', ascending=True)
did_list_test = did_list_test.sort_values(by='did', ascending=True)
# Merge splited devices with full dataset
df_train_merged = df_mess_pos_sort.merge(did_list_train, on='did')
df_test_merged = df_mess_pos_sort.merge(did_list_test, on='did')
# Write splitted data set to CSV
my_train_file = 'my_train_merged.csv'
df_train_merged.to_csv(my_train_file, sep=',', index=False)
my_test_file = 'my_test_merged.csv'
df_test_merged.to_csv(my_test_file, sep=',', index=False)
# Load train and test data
X_train = pd.read_csv('my_train_merged.csv') # train set
y_train = X_train[['lat','lng']]
# Plotting latitudes and longitudes of bs and messages for training and test sets
plt.figure(figsize=(10,5))
plt.plot(X_train['bs_lng'], X_train['bs_lat'], 'X', label='bs')
plt.plot(X_train[['lng']], X_train[['lat']], '.', label='message')
plt.xlabel('Longitude' )
plt.ylabel('Latitude')
plt.xlim(X_train['bs_lng'].min()-1, X_train['bs_lng'].max()+1)
plt.ylim(X_train['bs_lat'].min()-1, X_train['bs_lat'].max()+1)
plt.title('Distribution of latitudes and longitudes of message and bs for training set')
plt.legend()
Ces représentations sont cohérentes avec celles affichées sur cartes précédentes.
La première étape a consisté à calculer, pour chaque message reçu, quelle était la distance entre le message et les différentes stations l'ayant reçu. Concrètement nous avons ajouté une colonne "distance" au DataFrame en appelant la fonction de calcul de distance utilisant la formule de Haversine. Cette formule donne des résultats quasiment identiques à la formule proposée dans le dataset original mais sans les warning d'exécution.
#########################################
# Fonctions pour calculer les distances #
#########################################
def calc_distance_bs_message(X, pos):
""" Option 1 utilisant la formule de Haversine"""
df = X.join(pos)
mess_lat = df['lat'] * np.pi / 180
bs_lat = df['bs_lat'] * np.pi / 180
mess_lng = df['lng'] * np.pi / 180
bs_lng = df['bs_lng'] * np.pi / 180
dlon = bs_lng - mess_lng
dlat = bs_lat - mess_lat
R = 6373
a = (np.sin(dlat/2))**2 + np.cos(mess_lat) * np.cos(bs_lat) * (np.sin(dlon/2))**2
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))
distance = R * c
return distance
def vincenty_vec(vec_coord):
""" Option 2 avec la fonction fournie dans le notebook original """
vin_vec_dist = np.zeros(vec_coord.shape[0])
if vec_coord.shape[1] != 4:
print('ERROR: Bad number of columns (shall be = 4)')
else:
vin_vec_dist = [vincenty(vec_coord[m,0:2],vec_coord[m,2:]).meters for m in range(vec_coord.shape[0])]
return vin_vec_dist
#########################################
# Calcule des distances #
#########################################
# Defining distances of datasets and distances to keep (option 1)
X_train['distance'] = calc_distance_bs_message(X_train.drop(columns=['lat','lng']), X_train[['lat','lng']])
Nous avons ensuite choisi d'explorer la relation entre les distances calculées et la puissance du signal reçu. Cela nous a permis de détecter des outliers et notamment d'identifier plus précisément les stations qui étaient mal postionnées ou dont la distance par rapport au RSSI n'était pas cohérente.
En explorant ces données de façons expérimentales à partir des graphiques, il nous a semblé cohérent de trouver une équation d'atténuation du signal en fonction de la distance qui nous permettrait de séparer les stations bien et mal positionnées. Après plusieurs essais, nous avons opté pour la fonction de limite de distance suivante :
$$DistanceLimite(rssi) = 0.06* \exp^{- \frac{rssi}{20}}$$
Comme on peut le voir sur les graphiques ci-après en trait orange, cette fonction suit la répartition des points et nous permet d'avoir un critère d'identification précis des stations mal positionnées.
# Calcul de la distance limite des stations en fonction de la force du signal
d_keep_train = np.exp(- X_train['rssi'] / 20) * 0.06
# Plotting distances and rssi of bs and messages for training set
plt.figure(figsize=(16,4))
plt.subplot(1,2,1)
plt.plot(X_train['distance'], X_train['rssi'], 'o', label='trainig data')
plt.plot(d_keep_train, X_train['rssi'],'.', label='border delineating data to keep')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt.xlim(-100, X_train['distance'].max())
plt. title('rssi vs distance message/base id for training set')
plt.legend()
plt.subplot(1,2,2)
plt.plot(X_train['distance'], X_train['rssi'], 'o', label='trainig data')
plt.plot(d_keep_train, X_train['rssi'],'.', label='border delineating data to keep')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt. title('Zoom on 100 km distance')
plt.xlim(-10,100)
plt.legend()
Afin de prédire la position des stations aux positions aberrantes, nous avons décidé de recourir au machine learning en utilisant un algorithme de RandomForest.
Tout d'abord nous avons séparé les données avec d'un côté les stations que nous avons estimées bien placées grâce au travail précédent, et pour lesquelles nous avons la position exacte, et de l'autre, les stations dont nous cherchons à prédire la latitude et la longitude.
Nous avons ensuite entraîné notre algorithme de RandomForest sur les stations bien placées et réalisé une prédiction pour les autres stations.
Pour l'entraînement du modèle, nous avons décidé de garder le maximum d'information en incluant les champs suivants : "rssi", "time_ux", "nseq", "lat", "lng" dans la matrice de feature. Intuitivement, le rssi, la longitude et la latitude seraient les informations les plus pertinentes mais nous avons conservé les autres.
Enfin nous avons réalisé plusieurs graphiques pour vérifier que nous avions bien corrigé les points aberrants.
# Fonction de correction des longitudes et latitudes des stations mal positionnées
def correct_lat_lng_misplaced_bs(X, y, d_keep):
# On utlise un marqueur pour identifier les stations mal placées des autres.
# Les stations mal placées sont celles dont la distance aux messages est supérieure
# à la borde "d_keep" calculées précédemment.
df = X.join(y)
df['mul'] = 0
df['mul'] = df['mul'].where(df['distance'] > d_keep, 1)
df_1 = df.copy()
df_1 = df_1.loc[df_1['mul'] != 0]
df_2 = df.copy()
df_2 = df_2.loc[df_2['mul'] == 0]
X_train = df_1.copy()
X_test = df_2.copy()
# On utilise toutes les colonnes du DataFrame pour notre algorithme
col = ['rssi', 'time_ux','nseq','lat', 'lng']
X_train = X_train[col]
X_test = X_test[col]
ground_truth_lat = df_1['bs_lat']
ground_truth_lng = df_1['bs_lng']
# Entrainement d'une RandomForest
clr = RandomForestRegressor(n_estimators=100)
clr.fit(X_train, ground_truth_lat)
y_pred_lat = clr.predict(X_test)
clr.fit(X_train, ground_truth_lng)
y_pred_lng = clr.predict(X_test)
# Correction des positions
df_2['bs_lat'] = df_2['bs_lat'] * df_2['mul'] + y_pred_lat
df_2['bs_lng'] = df_2['bs_lng'] * df_2['mul'] + y_pred_lng
# Préparation du résultat final avec calcul des nouvelles distances
result = pd.concat([df_1, df_2])
result = result.drop(columns=['mul','distance'])
result['distance'] = calc_distance_bs_message(result.drop(columns=['lat','lng']), result[['lat','lng']])
result['d_keep'] = d_keep
return result
# Corrected data
X_train_c = correct_lat_lng_misplaced_bs(X_train.drop(columns=['lat','lng']), X_train[['lat','lng']], d_keep_train)
À la sortie de la fonction précédente, le DataFrame X_train_c comporte les positions corrigées des stations aux positions aberrantes, ce que l'on peut constater en retraçant les différents graphiques précédents. De plus, on a rajouté aux Dataframes X_train_c et X_test_c deux colonnes supplémentaires comportant les centroïdes (latitudes et longitudes) des stations ayant vu le même message.
# Plotting distances and rssi of bs and messages for corrected training set
plt.figure(figsize=(16,4))
plt.subplot(1,2,1)
plt.plot(X_train_c['distance'], X_train_c['rssi'], 'o', label='trainig data')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt. title('rssi vs distance message/base id for training set')
plt.xlim(-100, X_train['distance'].max())
plt.legend()
plt.subplot(1,2,2)
plt.plot(X_train_c['distance'], X_train_c['rssi'], 'o', label='trainig data')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt. title('Zoom on 100 km distance')
plt.xlim(-10,100)
plt.legend()
# Plotting latitudes and longitudes of bs and messages for training set
plt.figure(figsize=(16,6))
plt.subplot(1,2,1)
plt.plot(X_train['bs_lng'], X_train['bs_lat'], 'X', label='bs')
plt.plot(X_train['lng'], X_train['lat'], '.', label='message')
plt.xlabel('Longitude' )
plt.ylabel('Latitude')
plt.xlim(X_train['bs_lng'].min()-1, X_train['bs_lng'].max()+1)
plt.ylim(X_train['bs_lat'].min()-1, X_train['bs_lat'].max()+1)
plt.title('Distribution of latitudes and longitudes \n of message and bs for trainig set \
\n (original)')
plt.legend()
plt.subplot(1,2,2)
plt.plot(X_train_c['bs_lng'], X_train_c['bs_lat'], 'X', label='bs')
plt.plot(X_train['lng'], X_train['lat'], '.', label='message')
plt.xlabel('Longitude' )
plt.ylabel('Latitude')
plt.xlim(X_train['bs_lng'].min()-1, X_train['bs_lng'].max()+1)
plt.ylim(X_train['bs_lat'].min()-1, X_train['bs_lat'].max()+1)
plt.title('Distribution of latitudes and longitudes \n of message and bs for trainig set \
\n (corrected)')
plt.legend()
On constate que les stations aux positions aberrantes ou incohérentes ont été "ramenées" plus près du groupe de stations principal.
Tout d'abord on augmente de 0.5 la valeur de la colonne nseq pour eviter une division par 0. Ensuite on divise la colonne rssi par la valeur du nseq correspondant au message pour avantager les entrées qui ont un numéro de sequence plus petit. Cette nouvelle valeur du rssi est sauvegardée dans une nouvelle colonne appellé newrssi.
# Add penalty on the rssi
X_train_c['nseq'] = X_train_c['nseq'] + 0.5
X_train_c['newrssi'] = X_train_c['rssi'] / X_train_c['nseq']
On ajoutte deux colonnes aux datafram X_train_c comportant les centroïdes des stations ayant vu le même message.
# Add centroids columns to dataset
def add_centroid_bs(result):
df_3 = pd.DataFrame({'messid': result.groupby(['messid']).mean().index,
'bs_lng_centroid': result.groupby(['messid'])['bs_lng'].mean().values,
'bs_lat_centroid': result.groupby(['messid'])['bs_lat'].mean().values,
})
result = result.merge(df_3, on='messid')
return result
X_train_c = add_centroid_bs(X_train_c)
X_train_c.head()
Cette information va donner plus de poids aux stations les plus puissantes
# Add number of messages received by each station
def add_bsid_count_mess(result, bsid_count_mess):
messid_list = result[['messid']].drop_duplicates()
result = result.merge(bsid_count_mess, on='bsid')
result = result.merge(messid_list, on='messid')
return result
X_train_c = add_bsid_count_mess(X_train_c, df_bsid_count_mess)
X_train_c.head()
Nous avons introduit un certain nombre de nouvelles features pour notre jeu d'entraînement que nous garderons pour faire les predictions de positions de messages sur l'échantillon de validation. Ces nouvelles features sont:
La dernière feature (distance) s'est avérée être très utile pour réduire les erreurs commises en gélocalisation. Par contre, son calcul requiert une estimation des latitudes et longitudes des messages. Or, ces quantités sont in fine celles qu'on cherche à calculer. Notez que dans le cas de notre échantillon de validation, on dispose déjà de la vérité terrain pour les latitudes et longitudes, mais cela n'est pas le cas pour le jeu de test final sur lequel on sera évalué. Par conséquent, on a choisi de procéder aux calculs des positions des messages comme si on ne disposait pas de la vérité terrain, en passant par 2 itérations:
Ayant préparé le jeu d'entraînement, on peut procéder aux différents essais de prédictions de la position des messages pour les jeux de validation. On commence par charger le jeu de validation.
X_test = pd.read_csv('my_test_merged.csv') # test set
y_test = X_test[['lat','lng']]
Pour estimer grossièrement les positions des latitudes et des longitudes des messages, la matrice des features comporte :
# determine all Base stations that received at least 1 message
listOfBs = np.union1d(np.unique(X_train_c['bsid']), np.unique(X_test['bsid']))
def feat_mat_const_iter1(df_mess_train, listOfBs):
df_mess_bs_group = df_mess_train.groupby(['messid'], as_index=False) # group data by message (messid)
nb_mess = len(np.unique(df_mess_train['messid']))
df_feat = pd.DataFrame(np.zeros((nb_mess,len(listOfBs))), columns = listOfBs) # feature matrix
idx = 0
id_list = [0] * nb_mess
for key, elmt in df_mess_bs_group:
df_mess_bs_group.get_group(key)
df_feat.loc[idx,df_mess_bs_group.get_group(key)['bsid']] = 1
id_list[idx] = key
idx = idx + 1
return df_feat, id_list # add id value of each message for the correspondance to message
# Generate train features matrix
df_feat_1, id_list_train_1 = feat_mat_const_iter1(X_train_c, listOfBs)
df_feat_1.head()
# Generate test features matrix
df_feat_test_1, id_list_test_1 = feat_mat_const_iter1(X_test, listOfBs)
df_feat_test_1.head()
def regressor_and_predict(df_feat, ground_truth_lat, ground_truth_lng, df_test):
# train regressor and make prediction in the train set
# Input: df_feat, ground_truth_lat, ground_truth_lng, df_test
# Output: y_pred_lat, y_pred_lng
X_train = np.array(df_feat);
reg = ExtraTreesRegressor(n_estimators=100, n_jobs = -1, max_features=1.0, random_state=2792, bootstrap=True)
reg.fit(X_train, ground_truth_lat);
y_pred_lat = reg.predict(df_test)
print("Prédiction lat OK")
reg.fit(X_train, ground_truth_lng);
y_pred_lng = reg.predict(df_test)
print("Prédiction lng OK")
return y_pred_lat, y_pred_lng
Pour les valeurs "Ground_truth", nous avons utilisé la fonction proposés dans le cours.
# ground truth construction
def ground_truth_const(df_mess_train, pos_train):
df_mess_pos = df_mess_train.copy()
df_mess_pos[['lat', 'lng']] = pos_train
ground_truth_lat = np.array(df_mess_pos.groupby(['messid']).mean()['lat'])
ground_truth_lng = np.array(df_mess_pos.groupby(['messid']).mean()['lng'])
return ground_truth_lat, ground_truth_lng
gr_truth_lat_train, gr_truth_lng_train = ground_truth_const(X_train_c, X_train_c[['lat','lng']])
gr_truth_lat_test, gr_truth_lng_test = ground_truth_const(X_test, X_test[['lat','lng']])
print("Nb messages Train : {:d} \nNb messages Test : {:d}"\
.format(gr_truth_lat_train.shape[0],gr_truth_lat_test.shape[0] ))
y_pred_lat_1, y_pred_lng_1 = regressor_and_predict(df_feat_1, gr_truth_lat_train, gr_truth_lng_train,
df_feat_test_1)
On utilise les latitudes et longitudes calculées à partir du modèle de régression non linéaire utilisé lors de la première itération.
# Tmp dataframe
tmp = pd.DataFrame({'messid': id_list_test_1,
'lat': y_pred_lat_1,
'lng': y_pred_lng_1,
})
X_test['distance'] = calc_distance_bs_message(X_test.drop(columns=['lat', 'lng']),
X_test.drop(columns=['lat','lng']).merge(tmp, on='messid')[['lat','lng']])
# Calcul de la distance limite des stations en fonction de la force du signal
d_keep_test = np.exp(- X_test['rssi'] / 20) * 0.06
# Plotting distances and rssi of bs and messages for training set
plt.figure(figsize=(16,4))
plt.subplot(1,2,1)
plt.plot(X_test['distance'], X_test['rssi'], 'o', label='trainig data')
plt.plot(d_keep_test, X_test['rssi'],'.', label='border delineating data to keep')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt.xlim(-100, X_test['distance'].max())
plt. title('rssi vs distance message/base id for test set')
plt.legend()
plt.subplot(1,2,2)
plt.plot(X_test['distance'], X_test['rssi'], 'o', label='test data')
plt.plot(d_keep_test, X_test['rssi'],'.', label='border delineating data to keep')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt. title('Zoom on 100 km distance')
plt.xlim(-10,100)
plt.legend()
X_test_c = correct_lat_lng_misplaced_bs(X_test.drop(columns=['lat','lng']), X_test[['lat','lng']], d_keep_test)
À la sortie de la fonction précédente, le DataFrame X_test_c comporte les positions corrigées des stations aux positions aberrantes, ce que l'on peut constater en retraçant les différents graphiques précédents.
# Plotting distances and rssi of bs and messages for corrected test set
plt.figure(figsize=(16,4))
plt.subplot(1,2,1)
plt.plot(X_test_c['distance'], X_test_c['rssi'], 'o', label='test data')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt. title('rssi vs distance message/base id for testset')
plt.xlim(-100, X_train['distance'].max())
plt.legend()
plt.subplot(1,2,2)
plt.plot(X_test_c['distance'], X_test_c['rssi'], 'o', label='test data')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt. title('Zoom on 100 km distance')
plt.xlim(-10,100)
plt.legend()
# Add penalty on the rssi
X_test_c['nseq'] = X_test_c['nseq'] + 0.5
X_test_c['newrssi'] = X_test_c['rssi'] / X_test_c['nseq']
On ajoutte deux colonnes aux datafram X_test_c comportant les centroïdes des stations ayant vue le même message.
X_test_c = add_centroid_bs(X_test_c)
Cette information va donner plus de poids aux stations les plus puissantes.
X_test_c = add_bsid_count_mess(X_test_c, df_bsid_count_mess)
X_test_c.head()
Pour la matrice de features, nous avons décidé de garder le maximum d'informations possibles par rapport au jeu de données. Pour cela, nous avons transformé les lignes en colonnes de telle sorte que pour chaque message, on a non seulement la liste des stations qui l'a reçu mais également toutes les caractéristiques du message reçu pour chaque station.
Autrement dit notre matrice de feature comporte les élements suivants :
Après plusieurs tests, nous n'avons pas retenu la valeur "nseq" pour prédire la position des messages, car cette information ne nous semble pas suffisamment corrélée à la position. De même les résultats incluant la valeur de time_ux ne semblent pas apporter d'amélioration tels quels.
Nous avons également créé cette matrice de feature pour le jeu de validation.
# Feature Matrix construction
#def feat_mat_const(df_mess_train, df_count, listOfBs):
def feat_mat_const(df_mess_train, listOfBs):
lat_listOfBs = ['lat_' + s for s in list(listOfBs.astype(str))]
lng_listOfBs = ['lng_' + s for s in list(listOfBs.astype(str))]
dist_listOfBs = ['dist_' + s for s in list(listOfBs.astype(str))]
newrssi_listOfBs = ['newrssi_' + s for s in list(listOfBs.astype(str))]
dkeep_listOfBs = ['dkeep_' + s for s in list(listOfBs.astype(str))]
clat_listOfBs = ['clat_' + s for s in list(listOfBs.astype(str))]
clng_listOfBs = ['clng_' + s for s in list(listOfBs.astype(str))]
bscm_listOfBs = ['bscm_' + s for s in list(listOfBs.astype(str))]
#nseq_listOfBs = ['nseq_' + s for s in list(listOfBs.astype(str))]
#timeux_listOfBs = ['timeux_' + s for s in list(listOfBs.astype(str))]
feature_cols = list(listOfBs.astype(str)) \
+ lat_listOfBs \
+ lng_listOfBs \
+ dist_listOfBs \
+ newrssi_listOfBs \
+ dkeep_listOfBs \
+ clat_listOfBs \
+ clng_listOfBs \
+ bscm_listOfBs
#+ count_list
#+ nseq_listOfBs
#+ timeux_listOfBs
df_mess_bs_group = df_mess_train.groupby(['messid'], as_index=False) # group data by message (messid)
nb_mess = len(np.unique(df_mess_train['messid']))
df_feat = pd.DataFrame(np.zeros((nb_mess,len(feature_cols))), columns = feature_cols) # feature matrix
idx = 0
id_list = [0] * nb_mess
for key, elmt in df_mess_bs_group:
#print(key)
df_mess_bs_group.get_group(key)
bsid_list = df_mess_bs_group.get_group(key)['bsid'].astype(str)
lat_bsid_list = ['lat_' + s for s in list(bsid_list.astype(str))]
lng_bsid_list = ['lng_' + s for s in list(bsid_list.astype(str))]
dist_bsid_list = ['dist_' + s for s in list(bsid_list.astype(str))]
newrssi_bsid_list = ['newrssi_' + s for s in list(bsid_list.astype(str))]
dkeep_bsid_list = ['dkeep_' + s for s in list(bsid_list.astype(str))]
clat_bsid_list = ['clat_' + s for s in list(bsid_list.astype(str))]
clng_bsid_list = ['clng_' + s for s in list(bsid_list.astype(str))]
bscm_bsid_list = ['bscm_' + s for s in list(bsid_list.astype(str))]
#timeux_bsid_list = ['timeux_' + s for s in list(bsid_list.astype(str))]
rssi_list = df_mess_bs_group.get_group(key)['rssi']
bs_lat_list = df_mess_bs_group.get_group(key)['bs_lat']
bs_lng_list = df_mess_bs_group.get_group(key)['bs_lng']
bs_clat_list = df_mess_bs_group.get_group(key)['bs_lat_centroid']
bs_clng_list = df_mess_bs_group.get_group(key)['bs_lng_centroid']
dist_list = df_mess_bs_group.get_group(key)['distance']
bscm_list = df_mess_bs_group.get_group(key)['bs_count_mess']
newrssi_list = df_mess_bs_group.get_group(key)['newrssi']
dkeep_list = df_mess_bs_group.get_group(key)['d_keep']
#timeux_list = df_mess_bs_group.get_group(key)['time_ux']
#nseq_list = df_mess_bs_group.get_group(key)['nseq']
df_feat.loc[idx, bsid_list] = rssi_list.values
df_feat.loc[idx, lat_bsid_list] = bs_lat_list.values
df_feat.loc[idx, lng_bsid_list] = bs_lng_list.values
df_feat.loc[idx, clat_bsid_list] = bs_clat_list.values
df_feat.loc[idx, clng_bsid_list] = bs_clng_list.values
df_feat.loc[idx, dist_bsid_list] = dist_list.values
df_feat.loc[idx, bscm_bsid_list] = bscm_list.values
df_feat.loc[idx, newrssi_bsid_list] = newrssi_list.values
df_feat.loc[idx, dkeep_bsid_list] = dkeep_list.values
#df_feat.loc[idx, timeux_bsid_list] = timeux_list.values
#df_feat.loc[idx, nseq_bsid_list] = nseq_list.values
#df_feat.loc[idx, -1] = dkeep_list.values
id_list[idx] = key
idx = idx + 1
#df_feat['count'] = df_count['count']
return df_feat, id_list # add id value of each message for the correspondance to message
# Generate train features matrix
#df_feat, id_list_train = feat_mat_const(X_train_c, df_count_X_train_c, listOfBs)
df_feat, id_list_train = feat_mat_const(X_train_c, listOfBs)
#df_feat.head()
df_feat.shape
# Generate test features matrix
df_feat_test, id_list_test = feat_mat_const(X_test_c, listOfBs)
df_feat_test.head()
gr_truth_lat_train, gr_truth_lng_train = ground_truth_const(X_train_c.drop(columns=['lat','lng']), X_train_c[['lat','lng']])
gr_truth_lat_test, gr_truth_lng_test = ground_truth_const(X_test_c.drop(columns=['lat','lng']), X_test_c[['lat','lng']])
print("Nb messages Train : {:d} \nNb messages Test : {:d}"\
.format(gr_truth_lat_train.shape[0],gr_truth_lat_test.shape[0] ))
y_pred_lat, y_pred_lng = regressor_and_predict(df_feat, gr_truth_lat_train, gr_truth_lng_train,
df_feat_test)
L'évaluation des résultats est réalisée comme dans le notebook original en calculant les erreurs cumulées sur les prédictions.
# evaluate distance error for each predicted point
def Eval_geoloc(y_train_lat , y_train_lng, y_pred_lat, y_pred_lng):
vec_coord = np.array([y_train_lat , y_train_lng, y_pred_lat, y_pred_lng])
err_vec = vincenty_vec(np.transpose(vec_coord))
return err_vec
err_vec = Eval_geoloc(gr_truth_lat_test , gr_truth_lng_test, y_pred_lat, y_pred_lng)
# Error criterion
print(f"L'erreur à 80 % pour le jeu de données de validation est de {round(np.percentile(err_vec, 80),2)} m")
# Plot error distribution
values, base = np.histogram(err_vec, bins=50000)
cumulative = np.cumsum(values)
plt.figure();
plt.plot(base[:-1]/1000, cumulative / np.float(np.sum(values)) * 100.0, c='blue')
plt.grid(); plt.xlabel('Distance Error (km)'); plt.ylabel('Cum proba (%)'); plt.axis([0, 30, 0, 100]);
plt.title('Error Cumulative Probability'); plt.legend( ["Opt LLR", "LLR 95", "LLR 99"])
Commentaire
Ce résultat de 2 800 mètres nous a semblé plutôt satisfaisant, même si l'on aurait aimé arriver à encore plus corriger les choses.
# Plot of true and predicted locations for test set
plt.figure(figsize=(16,6))
plt.plot(X_test_c['bs_lng'], X_test_c['bs_lat'], 'ro', label='bs')
plt.plot(gr_truth_lng_test, gr_truth_lat_test, 'kX', label='True locations')
plt.plot(y_pred_lng, y_pred_lat, '.', label='Predicted locations')
plt.xlabel('Longitude' )
plt.ylabel('Latitude')
plt.title('True and predicted locations for test set')
plt.legend()
plt.figure(figsize=(16,6))
plt.subplot(1,2,1)
plt.plot(gr_truth_lng_test, y_pred_lng,'o')
plt.plot(gr_truth_lng_test, gr_truth_lng_test)
#plt.plot(gr_truth_lng_test, gr_truth_lng_test-0.3, 'k')
#plt.plot(gr_truth_lng_test, gr_truth_lng_test+0.3, 'k')
plt.xlabel('Ground truth lng')
plt.ylabel('Predicted lng')
plt.title('Predictions lng')
plt.subplot(1,2,2)
plt.plot(gr_truth_lat_test, y_pred_lat,'o', label='bs')
plt.plot(gr_truth_lat_test, gr_truth_lat_test)
#plt.plot(gr_truth_lat_test, gr_truth_lat_test-0.3, 'k')
#plt.plot(gr_truth_lat_test, gr_truth_lat_test+0.3, 'k')
plt.xlabel('Ground truth lat')
plt.ylabel('Predicted lat')
plt.title('Predictions lat')
Commentaire
D'après le premier graphique ci-dessus, on constate que la grappe centrale de messages semble plutôt bien prédite tandis qu'un certain nombre de messages ne sont pas bien prédits, notamment au sud est et au centre vers la longitude 104. Ceci est confirmé par le deuxième graphique.
X_train_f = pd.read_csv('mess_train_list.csv')
X_test_f = pd.read_csv('mess_test_list.csv')
y_train_f = pd.read_csv('pos_train_list.csv')
X_train_f = pd.concat([X_train_f, y_train_f], axis=1, sort=False)
X_train_f['distance'] = calc_distance_bs_message(X_train_f.drop(columns=['lat','lng']), X_train_f[['lat','lng']])
d_keep_train_f = np.exp(- X_train_f['rssi'] / 20) * 0.06
# Corrected data
X_train_fc = correct_lat_lng_misplaced_bs(X_train_f.drop(columns=['lat','lng']), X_train_f[['lat','lng']], d_keep_train_f)
# Add penalty on the rssi
X_train_fc['nseq'] = X_train_fc['nseq'] + 0.5
X_train_fc['newrssi'] = X_train_fc['rssi'] / X_train_fc['nseq']
# Centroid columns
X_train_fc = add_centroid_bs(X_train_fc)
# Add number of messages received by each station
X_train_fc = add_bsid_count_mess(X_train_fc, df_bsid_count_mess)
X_train_fc.head()
# determine all Base stations that received at least 1 message
listOfBs_f = np.union1d(np.unique(X_train_fc['bsid']), np.unique(X_test_f['bsid']))
# Generate train features matrix
df_feat_f1, id_list_train_f1 = feat_mat_const_iter1(X_train_fc, listOfBs_f)
df_feat_f1.head()
# Generate test features matrix
df_feat_test_f1, id_list_test_f1 = feat_mat_const_iter1(X_test_f, listOfBs_f)
df_feat_test_f1.head()
gr_truth_lat_train_f, gr_truth_lng_train_f = ground_truth_const(X_train_fc, X_train_fc[['lat','lng']])
print("Nb messages Train : {:d}"\
.format(gr_truth_lat_train_f.shape[0]))
y_pred_lat_f1, y_pred_lng_f1 = regressor_and_predict(df_feat_f1, gr_truth_lat_train_f, gr_truth_lng_train_f,
df_feat_test_f1)
# Tmp dataframe
tmp = pd.DataFrame({'messid': id_list_test_f1,
'lat': y_pred_lat_f1,
'lng': y_pred_lng_f1,
})
# Distance computation
X_test_f['distance'] = calc_distance_bs_message(X_test_f, X_test_f.merge(tmp, on='messid')[['lat','lng']])
# Adding the computed lat and lng columns
X_test_f['lat'] = X_test_f.merge(tmp, on='messid')['lat']
X_test_f['lng'] = X_test_f.merge(tmp, on='messid')['lng']
# Calcul de la distance limite des stations en fonction de la force du signal
d_keep_test_f = np.exp(- X_test_f['rssi'] / 20) * 0.06
# Plotting distances and rssi of bs and messages for test set
plt.figure(figsize=(16,4))
plt.subplot(1,2,1)
plt.plot(X_test_f['distance'], X_test_f['rssi'], 'o', label='trainig data')
plt.plot(d_keep_test_f, X_test_f['rssi'],'.', label='border delineating data to keep')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt.xlim(-100, X_test_f['distance'].max())
plt. title('rssi vs distance message/base id for test set')
plt.legend()
plt.subplot(1,2,2)
plt.plot(X_test_f['distance'], X_test_f['rssi'], 'o', label='test data')
plt.plot(d_keep_test_f, X_test_f['rssi'],'.', label='border delineating data to keep')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt. title('Zoom on 100 km distance')
plt.xlim(-10,100)
plt.legend()
# Correcting positions
X_test_fc = correct_lat_lng_misplaced_bs(X_test_f.drop(columns=['lat','lng']), X_test_f[['lat','lng']], d_keep_test_f)
# Plotting distances and rssi of bs and messages for corrected test set
plt.figure(figsize=(16,4))
plt.subplot(1,2,1)
plt.plot(X_test_fc['distance'], X_test_fc['rssi'], 'o', label='test data')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt. title('rssi vs distance message/base id for training set')
plt.xlim(-100, X_test_f['distance'].max())
plt.legend()
plt.subplot(1,2,2)
plt.plot(X_test_fc['distance'], X_test_fc['rssi'], 'o', label='test data')
plt.xlabel('Distance message / base id (km)' )
plt.ylabel('rssi (dBm)')
plt. title('Zoom on 100 km distance')
plt.xlim(-10,100)
plt.legend()
# Add penalty on the rssi
X_test_fc['nseq'] = X_test_fc['nseq'] + 0.5
X_test_fc['newrssi'] = X_test_fc['rssi'] / X_test_fc['nseq']
# Add centroids columns
X_test_fc = add_centroid_bs(X_test_fc)
# Add number of messages received by each station
X_test_fc = add_bsid_count_mess(X_test_fc, df_bsid_count_mess)
# Generate train features matrix
df_feat_f, id_list_train_f = feat_mat_const(X_train_fc, listOfBs_f)
df_feat_f.head()
# Generate test features matrix
df_feat_test_f, id_list_test_f = feat_mat_const(X_test_fc, listOfBs_f)
df_feat_test_f.head()
y_pred_lat_f, y_pred_lng_f = regressor_and_predict(df_feat_f, gr_truth_lat_train_f, gr_truth_lng_train_f,
df_feat_test_f)
test_res = pd.DataFrame(np.array([y_pred_lat_f, y_pred_lng_f]).T, columns = ['lat', 'lng'])
test_res['messid'] = id_list_test_f
test_res.to_csv('pred_pos_test_list.csv', index=False)
test_res.head()
Nous avons consacré beaucoup de temps à explorer et à tenter d'améliorer nos résultats... Nous espérons que l'erreur obtenue sur le fichier de validation final sera au moins aussi bonne que ce que nous avons obtenu avec nos données de test. De plus nous espérons que nos explicatins étaient suffisamment claires. Merci.